Skip to content

[kotlin] fix: enumUnknownDefaultCase's moshi fallback adapters break nullable enum fields - #24894

Open
wiebren wants to merge 1 commit into
OpenAPITools:masterfrom
wiebren:fix/kotlin-enum-unknown-default-null-safe
Open

wiebren wants to merge 1 commit into
OpenAPITools:masterfrom
wiebren:fix/kotlin-enum-unknown-default-null-safe

Conversation

@wiebren

@wiebren wiebren commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

enumUnknownDefaultCase=true does what it promises for unknown wire values in the moshi
kotlin client: every enum gains an unknown_default_open_api member and
SerializerHelper.addEnumUnknownDefaultCase registers a
com.squareup.moshi.adapters.EnumJsonAdapter with withUnknownFallback per enum. But
EnumJsonAdapter is not null-safe, and the helper registers it bare — so any model with
a nullable enum property now throws on a null value, reading and writing alike:

java.lang.NullPointerException: value was null! Wrap in .nullSafe() to write nullable values.

Concretely: serializing an update command whose optional enum field is unset, or
deserializing a response whose optional enum field is null. Found while running a generated
kotlin client (jvm-okhttp, moshi) against a production registry API on v7.15.0: enabling the
flag made unknown enum values tolerated and simultaneously broke every request/response with
an absent optional enum field. Without the flag, moshi's built-in enum handling is null-safe
and the same payloads round-trip fine — the flag is a strict regression for nullable enum
fields.

The fix

Append .nullSafe() to each registered adapter in
jvm-common/infrastructure/SerializerHelper.kt.mustache, in both the top-level-enum and
inline-enum branches. withUnknownFallback returns EnumJsonAdapter, .nullSafe() wraps it
as a JsonAdapter — which is what Moshi.Builder.add(Type, JsonAdapter) takes — so the
change is two template lines.

Sibling of #24878 (php) and #24879 (ruby): the same flag, half-implemented in a different
way per language. No shared main/ code.

Tests

KotlinClientCodegenModelTest#testMoshiEnumUnknownDefaultCaseAdaptersAreNullSafe generates
from the existing 3_0/enum.yaml fixture with the flag on and asserts every registered
adapter is wrapped. Fails without the template change (verified by stashing only the
template).

PR checklist


Generated with Claude Code


Summary by cubic

Enabling enumUnknownDefaultCase in the Moshi Kotlin client no longer breaks nullable enum fields. Previously, the fallback EnumJsonAdapter was registered bare, so any null enum value threw a NullPointerException on read and write; the adapters are now wrapped with .nullSafe() in both the top-level and inline enum branches.

Tests

  • Adds a generator test asserting every registered enum fallback adapter is null-safe.
  • Updates the kotlin-enum-default-value sample to reflect the new .nullSafe() calls.

Written for commit 0cb5a0d. Summary will update on new commits.

Review in cubic

… null-safe

SerializerHelper registers a moshi EnumJsonAdapter with withUnknownFallback
per enum, but EnumJsonAdapter is not null-safe and it is registered bare:
any model with a nullable enum property throws "value was null! Wrap in
.nullSafe() to write nullable values" on a null value, reading and writing
alike. The flag traded unknown-value tolerance for a regression on every
optional enum field. Append .nullSafe() in both branches.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GcwZ1arjLZNpetHz2a3TJz

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found across 3 files

Re-trigger cubic

@thejeff77

Copy link
Copy Markdown
Contributor

Confirming the diagnosis: EnumJsonAdapter from moshi-adapters is not null-safe, and registering it per enum class shadows moshi's built-in enum adapter, which is — so turning enumUnknownDefaultCase on breaks every enum property that is not in required.

We hit this in production. It is worth spelling out where it fires, because it is not only a decode problem. The generated ApiClient puts every JSON request body through:

Serializer.moshi.adapter(T::class.java).toJson(content)

That is the outbound path. Any request whose model has a null optional enum throws before it leaves the client. We reverted enabling the flag across our services because of it.

I built and ran both sides rather than reading the diff.

What I ran

  • 7.26.0-SNAPSHOT built from source, Amazon Corretto 21.0.8, macOS arm64
  • master f201f33b vs this PR 0cb5a0d7
  • ./mvnw -B -pl modules/openapi-generator-cli -am -DskipTests package
  • generate -g kotlin --additional-properties=enumUnknownDefaultCase=true (moshi default)
  • spec with a required inline enum, an optional inline enum, an optional $ref'd enum, and an array of inline enums
  • compiled the generated client with its own gradle wrapper and ran assertions against Serializer.moshi

Generated output between the two jars differs only in the four .nullSafe() calls.

master f201f33b

[1-encode-optional-enum-unset]
  expected: no throw; json omits optionalColor/optionalStatus/tags
  actual:   THROW-> java.lang.NullPointerException: value was null! Wrap in .nullSafe() to write nullable values.

[2-decode-explicit-null]
  expected: optionalColor == null and optionalStatus == null
  actual:   THROW-> com.squareup.moshi.JsonDataException: Expected a string but was NULL at path $.optionalColor

[3-decode-unknown-value]
  actual:   OK   -> optionalColor=unknown_default_open_api optionalStatus=unknown_default_open_api

[4-decode-known-value]
  actual:   OK   -> requiredColor=GREEN optionalColor=BLUE optionalStatus=ACTIVE tags=[A, C]

=== FAILURES: 2 ===

this PR 0cb5a0d7

[1-encode-optional-enum-unset]
  actual:   OK   -> json={"id":"w1","requiredColor":"RED"}

[2-decode-explicit-null]
  actual:   OK   -> optionalColor=null optionalStatus=null

[3-decode-unknown-value]
  actual:   OK   -> optionalColor=unknown_default_open_api optionalStatus=unknown_default_open_api

[4-decode-known-value]
  actual:   OK   -> requiredColor=GREEN optionalColor=BLUE optionalStatus=ACTIVE tags=[A, C]

=== FAILURES: 0 ===

Checks 3 and 4 pass on both sides, which is the part I most wanted to see: .nullSafe() wraps the adapter without disturbing the unknown-value fallback the flag exists for.

The new unit test discriminates. It passes on this branch. Applied on top of master with the template left alone, it fails:

[ERROR] KotlinClientCodegenModelTest.testMoshiEnumUnknownDefaultCaseAdaptersAreNullSafe
java.lang.AssertionError: File '.../infrastructure/SerializerHelper.kt'
  does not contain line [.nullSafe())] expected [true] but found [false]

A few things I checked while I was in there

  • SerializerHelper.kt.mustache is the only template in the repo that registers an EnumJsonAdapter, and both of its branches are covered by this diff. Nothing else in the kotlin templates needs the same treatment.
  • Coverage is broader than the sample suggests. I generated top-level $ref'd enums, inline property enums, array-item enums and enums on nested objects — all get registered, and all get .nullSafe().
  • No regression on required fields. An explicit null for a required enum is still rejected, now with Non-null value 'reqEnum' was null at $.reqEnum rather than the moshi-internal message. A missing required enum still gives Required value 'reqEnum' missing at $.

One likely reason this went unnoticed for so long: enumUnknownDefaultCase is not advertised for kotlin at all. It is absent from config-help -g kotlin and from docs/generators/kotlin.md, though it works — I get 0 occurrences for kotlin and 1 for java from the same jar. That is a separate problem from this PR (it traces back to #12970) and I am happy to take it separately.

If it is useful, the reproduction is on a branch: https://github.com/thejeff77/openapi-generator/tree/chore/verify-kotlin-enum-nullsafe-24894/enum-nullsafe-repro — a spec, a short main(), and a README with the exact commands. Nothing there needs to land here.

Thanks for writing this up, @wiebren. The diagnosis and the fix both look right to me. Glad to help move it along — more cases, a runtime test in the samples, or a rebase onto current master, whichever is most useful.

@thejeff77

Copy link
Copy Markdown
Contributor

@wiebren — this has been sitting for two weeks with green CI and no triage, so here is what has actually worked for me on Kotlin PRs in this repo, in case it is useful.

Tagging the technical committee mostly does not get a response. You did it correctly in the description, but the listed Kotlin members have reviewed almost nothing this year — counting reviews since January: 4brunu 10 (nine of them Swift), jimschubert 1 (not Kotlin), Zomzog 1, and andrewemery / stefankoppier / karismann zero. I tagged the full committee on two of my own PRs and got no committee response either time.

What worked was Slack. On #23444 I tagged the committee, bumped after a week, then heard nothing for six weeks — until @wing328 commented asking me to resolve conflicts and then "PM me via Slack to have this reviewed and merged". It merged the next day. There is a public invite link at the top of the README.

Two things that should make that conversation short:

  • You do not need a rebase. The branch is 48 commits behind master but the GitHub API reports mergeable_state: clean — no conflicts. Worth saying up front, since "please rebase" is a common first response and it would cost you another round trip for nothing.
  • 7.26.0 is due 2026-09-24. The milestone gets attached at merge, and this PR currently has no label and no milestone, so it is not on anyone's list for the release. Asking explicitly whether it can make 7.26.0 gives the request a deadline.

For whatever it is worth as supporting evidence: I rebuilt both sides and ran the generated clients — details in my earlier comment. The fix is correct and complete, your test genuinely discriminates, and we reverted enabling this flag across our services because of the bug you are fixing. Happy to add a +1 wherever it helps.

@wiebren

wiebren commented Sep 21, 2026

Copy link
Copy Markdown
Contributor Author

@thejeff77 thanks for rebuilding both sides and running the generated clients; that is more thorough than the PR's own test, and the outbound toJson path is a good catch that the description underplays. Thanks also for the pointers on getting it looked at, and for checking that it still merges cleanly.

The missing enumUnknownDefaultCase entry in kotlin's config-help and docs/generators/kotlin.md is worth its own issue if you are up for filing it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants